package com.google.gdata.client.http;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.GZIPInputStream;

import com.google.appengine.api.urlfetch.HTTPHeader;
import com.google.appengine.api.urlfetch.HTTPMethod;
import com.google.appengine.api.urlfetch.HTTPRequest;
import com.google.appengine.api.urlfetch.HTTPResponse;
import com.google.appengine.api.urlfetch.URLFetchService;
import com.google.appengine.api.urlfetch.URLFetchServiceFactory;
import com.google.gdata.client.AuthTokenFactory;
import com.google.gdata.client.GDataProtocol;
import com.google.gdata.client.Query;
import com.google.gdata.client.GDataProtocol.Header;
import com.google.gdata.client.Service.GDataRequest;
import com.google.gdata.client.Service.GDataRequestFactory;
import com.google.gdata.data.DateTime;
import com.google.gdata.data.ParseSource;
import com.google.gdata.util.AuthenticationException;
import com.google.gdata.util.ContentType;
import com.google.gdata.util.EntityTooLargeException;
import com.google.gdata.util.InvalidEntryException;
import com.google.gdata.util.LoggableInputStream;
import com.google.gdata.util.LoggableOutputStream;
import com.google.gdata.util.NoLongerAvailableException;
import com.google.gdata.util.NotAcceptableException;
import com.google.gdata.util.NotImplementedException;
import com.google.gdata.util.NotModifiedException;
import com.google.gdata.util.PreconditionFailedException;
import com.google.gdata.util.ResourceNotFoundException;
import com.google.gdata.util.ServiceException;
import com.google.gdata.util.ServiceForbiddenException;
import com.google.gdata.util.VersionConflictException;

public class HttpGDataRequest implements GDataRequest {

	static final Logger logger = Logger.getLogger(HttpGDataRequest.class
			.getName());

	/**
	 * If this system property is set to <code>true</code>, the GData HTTP
	 * client library will use POST to send data to the associated GData service
	 * and will specify the actual method using the {@code
	 * METHOD_OVERRIDE_HEADER} HTTP header. This can be used as a workaround for
	 * HTTP proxies or gateways that do not handle PUT or DELETE HTTP methods
	 * properly. If the system property is <code>false</code>, the regular PUT
	 * and DELETE HTTP verbs will be used.
	 */
	public static final String METHOD_OVERRIDE_PROPERTY = "com.google.gdata.UseMethodOverride";

	/**
	 * Name of HTTP header containing the method name that overrides the normal
	 * HTTP method.
	 * 
	 * @deprecated Use {@link GDataProtocol.Header#METHOD_OVERRIDE} instead
	 */
	@Deprecated
	public static final String METHOD_OVERRIDE_HEADER = GDataProtocol.Header.METHOD_OVERRIDE;

	public static class Factory implements GDataRequestFactory {
		protected HttpAuthToken authToken;
		protected Map<String, String> headerMap = new LinkedHashMap<String, String>();
		protected Map<String, String> privateHeaderMap = new LinkedHashMap<String, String>();
		protected boolean useSsl = false;
		protected URLFetchService urlFetchService = URLFetchServiceFactory
				.getURLFetchService();

		@Override
		public GDataRequest getRequest(Query query, ContentType contentType)
				throws IOException, ServiceException {
			return getRequest(RequestType.QUERY, query.getUrl(), contentType);
		}

		public GDataRequest getRequest(RequestType type, URL requestUrl,
				ContentType contentType) throws IOException, ServiceException {
			if (this.useSsl && !requestUrl.getProtocol().startsWith("https")) {
				requestUrl = new URL(requestUrl.toString().replaceFirst("http",
						"https"));
			}
			return createRequest(type, requestUrl, contentType);
		}

		@Override
		public void setAuthToken(AuthTokenFactory.AuthToken authToken) {
			if (authToken != null && !(authToken instanceof HttpAuthToken)) {
				throw new IllegalArgumentException("Invalid token type");
			}
			setAuthToken((HttpAuthToken) authToken);
		}

		public void setAuthToken(HttpAuthToken authToken) {
			this.authToken = authToken;
		}

		public void useSsl() {
			this.useSsl = true;
		}

		@Override
		public void setHeader(String header, String value) {
			extendHeaderMap(this.headerMap, header, value);
		}

		@Override
		public void setPrivateHeader(String header, String value) {
			extendHeaderMap(this.privateHeaderMap, header, value);
		}

		private void extendHeaderMap(Map<String, String> headerMap,
				String header, String value) {
			if (value == null) {
				headerMap.remove(header);
			} else {
				headerMap.put(header, value);
			}
		}

		/**
		 * Creates a {@link GDataRequest} instance.
		 * 
		 * <p>
		 * This method is called from {@link #getRequest} after any changes to
		 * the parameters have been applied.
		 * 
		 * <p>
		 * Subclasses should overwrite this method and not {@link #getRequest}
		 */
		protected GDataRequest createRequest(RequestType type, URL requestUrl,
				ContentType contentType) throws IOException, ServiceException {
			return new HttpGDataRequest(type, requestUrl, contentType,
					authToken, headerMap, privateHeaderMap, urlFetchService);
		}
	}

	/**
	 * Source of {@link URLFetchService} instances.
	 */
	protected final URLFetchService urlFetchService;

	/**
	 * The request URL provided by the client.
	 */
	protected URL requestUrl;

	/**
	 * HTTPRequest.
	 */
	protected HTTPRequest httpReq;

	/**
	 * HTTPResponse.
	 */
	protected HTTPResponse httpResp;

	/**
	 * The GData request type.
	 */
	protected RequestType type;

	/**
	 * Indicates whether request execution has taken place. Set to
	 * <code>true</code> if executed, <code>false</code> otherwise.
	 */
	protected boolean executed = false;

	/**
	 * True if the request type expects input from the client.
	 */
	protected boolean expectsInput;

	/**
	 * Contains the content type of the request data
	 */
	protected ContentType inputType;

	/**
	 * True if the request type returns output to the client.
	 */
	protected boolean hasOutput;

	/**
	 * The connection timeout for this request. A value of -1 means no value has
	 * been configured (use JDK default timeout behavior).
	 */
	protected int connectTimeout = -1;

	/**
	 * The read timeout for this request. A value of -1 means no value has been
	 * configured (use JDK default timeout behavior).
	 */
	protected int readTimeout = -1;

	/**
	 * The input stream from which HTTP response data may be read or {@code
	 * null} if no response stream or not opened yet via
	 * {@link #getResponseStream()}.
	 */
	private InputStream inputStream = null;

	private ByteArrayOutputStream outputStream = null;
	
	/**
	 * Constructs a new HttpGDataRequest instance of the specified RequestType,
	 * targeting the specified URL.
	 * 
	 * @param type
	 *            type of GDataRequest.
	 * @param requestUrl
	 *            request target URL.
	 * @param inputType
	 *            the content type of request data (or {@code null}).
	 * @param headerMap
	 *            a set of headers to be included in each request
	 * @param privateHeaderMap
	 *            a set of headers to be included in each request
	 * @param connectionSource
	 *            source of {@link HttpURLConnection}s
	 * @throws IOException
	 *             on error initializating service connection.
	 */
	protected HttpGDataRequest(RequestType type, URL requestUrl,
			ContentType inputType, HttpAuthToken authToken,
			Map<String, String> headerMap,
			Map<String, String> privateHeaderMap,
			URLFetchService urlFetchService) throws IOException {

		this.urlFetchService = urlFetchService;
		this.type = type;
		this.inputType = inputType;
		this.requestUrl = requestUrl;
		// this.httpConn = getRequestConnection(requestUrl);

		switch (type) {

		case QUERY:
			hasOutput = true;
			setMethod(HTTPMethod.GET);
			setHeader("Content-Type", "application/x-www-form-urlencoded");
			break;

		case INSERT:
		case BATCH:
			expectsInput = true;
			hasOutput = true;
			setMethod(HTTPMethod.POST);
			setHeader("Content-Type", inputType.toString());
			break;

		case UPDATE:
			expectsInput = true;
			hasOutput = true;
			if (Boolean.getBoolean(METHOD_OVERRIDE_PROPERTY)) {
				setMethod(HTTPMethod.POST);
				setHeader(Header.METHOD_OVERRIDE, "PUT");
			} else {
				setMethod(HTTPMethod.PUT);
			}
			setHeader("Content-Type", inputType.toString());
			break;

		case PATCH:
			expectsInput = true;
			hasOutput = true;

			// HTTPUrlConnection does not accept unrecognized methods, so always
			// use
			// the POST override model for PATCH requests
			setMethod(HTTPMethod.POST);
			setHeader(Header.METHOD_OVERRIDE, "PATCH");
			setHeader("Content-Type", inputType.toString());
			break;

		case DELETE:
			if (Boolean.getBoolean(METHOD_OVERRIDE_PROPERTY)) {
				setMethod(HTTPMethod.POST);
				setHeader(Header.METHOD_OVERRIDE, "DELETE");
			} else {
				setMethod(HTTPMethod.DELETE);
			}
			setHeader("Content-Length", "0"); // no data to POST
			break;

		default:
			throw new UnsupportedOperationException("Unknown request type:"
					+ type);
		}

		if (authToken != null) {
			// NOTE: Do not use setHeader() here, authorization should never be
			// logged.
			String authHeader = authToken.getAuthorizationHeader(requestUrl,
					httpReq.getMethod().name());
			setPrivateHeader("Authorization", authHeader);
		}

		if (headerMap != null) {
			for (Map.Entry<String, String> e : headerMap.entrySet()) {
				setHeader(e.getKey(), e.getValue());
			}
		}

		if (privateHeaderMap != null) {
			for (Map.Entry<String, String> e : privateHeaderMap.entrySet()) {
				setPrivateHeader(e.getKey(), e.getValue());
			}
		}

		// Request compressed response format
		setHeader("Accept-Encoding", "gzip");
		// setHeader("GData-Version", "3.0");

		// httpConn.setDoOutput(expectsInput);

	}

	public void setMethod(HTTPMethod method) throws ProtocolException {
		this.httpReq = new HTTPRequest(requestUrl, method);
	}

	@Override
	public void end() {
		try {
			if (inputStream != null) {
				inputStream.close();
			}
		} catch (IOException ioe) {
			logger.log(Level.WARNING, "Error closing response stream", ioe);
		}
	}

	@Override
	public void execute() throws IOException, ServiceException {

		if (outputStream != null) {
			httpReq.setPayload(outputStream.toByteArray());
		}

		Future<HTTPResponse> asyncHttpResp = urlFetchService
				.fetchAsync(httpReq);

		String value = null;
		for (HTTPHeader header : httpReq.getHeaders()) {

			if (header.getName().equalsIgnoreCase("Authorization")) {
				value = header.getValue();
				logger.warning("Authorization:" + value);
				
				break;
			}
		}
		
		try {
			httpResp = asyncHttpResp.get();

		} catch (InterruptedException e) {
			logger.warning("InterruptedException:" + e);

		} catch (ExecutionException e) {
			logger.warning("ExecutionException:" + e);

		}

		checkResponse(); // will flush any request data
		executed = true;

	}

	/**
	 * Called after a request is executed to process the response and generate
	 * an appropriate exception (on failure).
	 */
	protected void checkResponse() throws IOException, ServiceException {

		if (httpResp.getResponseCode() >= 300) {
			handleErrorResponse();
		}
		/*
		 * else if (isOAuthProxyErrorResponse()) {
		 * handleOAuthProxyErrorResponse(); }
		 */
	}

	/**
	 * Handles an error response received while executing a GData service
	 * request. Throws a {@link ServiceException} or one of its subclasses,
	 * depending on the failure conditions.
	 * 
	 * @throws ServiceException
	 *             exception describing the failure.
	 * @throws IOException
	 *             error reading the error response from the GData service.
	 */
	protected void handleErrorResponse() throws ServiceException, IOException {

		switch (httpResp.getResponseCode()) {

		case HttpURLConnection.HTTP_NOT_FOUND:
			throw new ResourceNotFoundException(new String(httpResp
					.getContent()));

		case HttpURLConnection.HTTP_BAD_REQUEST:
			throw new InvalidEntryException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_FORBIDDEN:
			throw new ServiceForbiddenException(new String(httpResp
					.getContent()));

		case HttpURLConnection.HTTP_UNAUTHORIZED:
			throw new AuthenticationException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_NOT_MODIFIED:
			throw new NotModifiedException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_PRECON_FAILED:
			throw new PreconditionFailedException(new String(httpResp
					.getContent()));

		case HttpURLConnection.HTTP_NOT_IMPLEMENTED:
			throw new NotImplementedException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_CONFLICT:
			throw new VersionConflictException();

		case HttpURLConnection.HTTP_ENTITY_TOO_LARGE:
			throw new EntityTooLargeException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_NOT_ACCEPTABLE:
			throw new NotAcceptableException(new String(httpResp.getContent()));

		case HttpURLConnection.HTTP_GONE:
			throw new NoLongerAvailableException(new String(httpResp
					.getContent()));

		default:
			throw new ServiceException(new String(httpResp.getContent()));
		}
	}

	@Override
	public ParseSource getParseSource() throws IOException {
		return new ParseSource(getResponseStream());
	}

	@Override
	public ContentType getRequestContentType() {
		return inputType;
	}

	@Override
	public OutputStream getRequestStream() throws IOException {

		if (outputStream == null) {
			outputStream = new ByteArrayOutputStream();
		}

		if (!expectsInput) {
			throw new IllegalStateException("Request doesn't accept input");
		}
		if (logger.isLoggable(Level.FINEST)) {
			return new LoggableOutputStream(logger, outputStream);
		}
		
		return outputStream;
	}

	@Override
	public URL getRequestUrl() {
		return requestUrl;
	}

	@Override
	public ContentType getResponseContentType() {

		if (!executed) {
			throw new IllegalStateException(
					"Must call execute() before attempting to read response");
		}

		String value = getResponseHeader("Content-Type");
		if (value == null) {
			return null;
		}
		return new ContentType(value);
	}

	@Override
	public DateTime getResponseDateHeader(String headerName) {
		long dateValue = getHeaderFieldDate(headerName, -1);
		return (dateValue >= 0) ? new DateTime(dateValue) : null;
	}

	public String getResponseHeader(String name) {
		String value = null;
		for (HTTPHeader header : httpResp.getHeaders()) {

			if (header.getName().equalsIgnoreCase(name)) {
				value = header.getValue();
				break;
			}
		}
		return value;
	}

	public long getHeaderFieldDate(String name, long Default) {
		String dateString = getResponseHeader(name);
		try {
			if (dateString.indexOf("GMT") == -1) {
				dateString = dateString + " GMT";
			}
			return Date.parse(dateString);
		} catch (Exception e) {
		}
		return Default;
	}

	@Override
	public InputStream getResponseStream() throws IOException {

		if (!executed) {
			throw new IllegalStateException(
					"Must call execute() before attempting to read response");
		}

		if (!hasOutput) {
			throw new IllegalStateException(
					"Request doesn't have response data");
		}

		if (inputStream != null) {
			return inputStream;
		}

		inputStream = new ByteArrayInputStream(httpResp.getContent());

		if ("gzip".equalsIgnoreCase(getResponseHeader("Content-Encoding"))) {
			inputStream = new GZIPInputStream(inputStream);
		}
		if (logger.isLoggable(Level.FINEST)) {
			return new LoggableInputStream(logger, inputStream);
		}

		return inputStream;
	}

	public void setConnectTimeout(int timeout) {
		if (timeout < 0) {
			throw new IllegalArgumentException("Timeout cannot be negative");
		}
		connectTimeout = timeout;
	}

	public void setReadTimeout(int timeout) {
		if (timeout < 0) {
			throw new IllegalArgumentException("Timeout cannot be negative");
		}
		readTimeout = timeout;
	}

	public void setIfModifiedSince(DateTime conditionDate) {
		if (conditionDate == null) {
			return;
		}

		if (type == RequestType.QUERY) {
			setHeader(GDataProtocol.Header.IF_MODIFIED_SINCE, conditionDate
					.toStringRfc822());
		} else {
			throw new IllegalStateException(
					"Date conditions not supported for this request type");
		}
	}

	public void setEtag(String etag) {

		if (etag == null) {
			return;
		}

		switch (type) {
		case QUERY:
			if (etag != null) {
				setHeader(GDataProtocol.Header.IF_NONE_MATCH, etag);
			}
			break;
		case PATCH:
		case UPDATE:
		case DELETE:
			if (etag != null) {
				setHeader(GDataProtocol.Header.IF_MATCH, etag);
			}
			break;
		default:
			throw new IllegalStateException(
					"Etag conditions not supported for this request type");
		}
	}

	public void setHeader(String name, String value) {
		HTTPHeader header = new HTTPHeader(name, value);
		httpReq.addHeader(header);
		logger.finer(name + ": " + value);
	}

	public void setPrivateHeader(String name, String value) {
		HTTPHeader header = new HTTPHeader(name, value);
		httpReq.addHeader(header);
		logger.finer(name + ": <Not Logged>");
	}

}
